2.04. Фоновые процессы и работа без интернета
Фоновые процессы и работа веб-приложений без интернета
Традиционная веб-страница представляет собой статический или динамически сгенерированный документ, жизненный цикл которого ограничен временем открытия вкладки до её закрытия. Выполнение JavaScript-кода полностью зависит от активности окна браузера: при сворачивании вкладки или переходе на другой сайт большинство задач приостанавливается, а при закрытии — полностью прекращается. Это соответствует изначальной парадигме World Wide Web: клиент делает запрос — сервер отвечает — клиент отображает результат. Связь однократна, состояние — эфемерно, автономность — невозможна.
Современные веб-приложения, однако, стремятся к уровню функциональности, привычному в нативных средах: работа в офлайне, фоновая загрузка, push-уведомления, фоновая синхронизация изменений, обновление содержимого без участия пользователя. Такие требования выходят за рамки классической клиент-серверной модели и требуют фундаментального пересмотра архитектуры выполнения кода на стороне клиента. Здесь ключевым становится понятие автономности, а техническим фундаментом — фоновые процессы.
Что такое автономность в контексте веб-приложений?
Автономность — это способность приложения функционировать при отсутствии стабильного или вообще любого сетевого соединения с сервером. Это не просто «открытие сохранённой копии страницы», а полноценное выполнение бизнес-логики: редактирование данных, навигация по интерфейсу, локальная валидация, реакция на действия пользователя — всё это должно происходить без задержек, вызванных ожиданием ответа от сервера.
Важно подчеркнуть: автономность не означает полную независимость от сервера. Она подразумевает временную декомпозицию взаимодействия. Приложение продолжает работать, накапливая изменения локально; в моменты, когда связь восстанавливается, оно автоматически или по инициативе пользователя согласует своё состояние с сервером. Таким образом, автономность — это не альтернатива онлайн-режиму, а его надстройка, обеспечивающая устойчивость к сетевым сбоям и повышающая воспринимаемую отзывчивость.
Степень автономности может варьироваться:
- Частичная — доступна только часть функционала (например, чтение ранее загруженных статей, но не публикация комментариев).
- Полная — все ключевые операции возможны локально, включая создание, редактирование и удаление сущностей; синхронизация происходит прозрачно для пользователя.
Достижение автономности требует комплексного подхода, затрагивающего хранилище, сетевую логику, управление состоянием и интерфейс. Основной механизм, делающий это возможным в современном вебе, — вынос части логики выполнения за пределы жизненного цикла страницы.
Фоновые процессы
Фоновый процесс — это задача, выполняемая браузером независимо от активности вкладки и даже после её закрытия. Это принципиальный сдвиг: JavaScript, изначально ограниченный sandbox’ом документа, получает возможность «жить» дольше самого документа.
Такая возможность не появилась внезапно. Её предпосылками стали:
- Повсеместное распространение мобильных устройств с нестабильными и дорогими каналами связи.
- Рост требований к UX: пользователи ожидают мгновенного отклика, как в нативных приложениях.
- Ограничения push-уведомлений и фоновой загрузки в нативных веб-приложениях (PWA), которые требовали стандартизированных, безопасных и энергоэффективных решений.
Важно чётко разделять фоновую работу от псевдо-фоновых практик:
setTimeout/setIntervalс большими задержками не являются фоновыми: они приостанавливаются при неактивности вкладки.requestIdleCallbackилиvisibilitychangeпозволяют отложить выполнение, но не гарантируют его после закрытия вкладки.- Web Workers работают в отдельном потоке, но их жизненный цикл всё равно привязан к странице-родителю.
Настоящая фоновая работа в вебе возможна только через Service Worker и API, построенные на его основе. Service Worker — это скрипт, зарегистрированный браузером как системный обработчик событий, связанных с сетью, временем, push-каналами и фоновыми задачами. Он запускается по событию (например, входящий push, таймер, необходимость синхронизации), выполняет необходимые действия и завершается. Такой подход минимизирует энергопотребление и предотвращает неограниченное выполнение произвольного кода.
Синхронизация
Синхронизация — центральное понятие в архитектуре автономных приложений. Она представляет собой процесс достижения согласованности данных между локальным клиентским состоянием и удалённым серверным состоянием.
Синхронизация не всегда подразумевает немедленный обмен. Её можно классифицировать по нескольким измерениям:
По направлению:
- Однонаправленная (push/pull) — данные обновляются только с сервера на клиент (например, обновление новостей) или только с клиента на сервер (например, сохранение черновика).
- Двунаправленная — изменения возможны с обеих сторон; требуется механизм разрешения конфликтов (например, два пользователя редактируют один документ).
По времени:
- Мгновенная (real-time) — изменения передаются и применяются сразу (WebSockets, SSE). Требует постоянного соединения.
- Отложенная (deferred) — действия ставятся в очередь и выполняются при удобном случае: при восстановлении сети, при следующем запуске приложения, в фоне. Именно отложенная синхронизация лежит в основе автономной работы.
По инициатору:
- Пользовательская — синхронизация запускается по явному действию (кнопка «Сохранить и отправить»).
- Системная — происходит автоматически, без участия пользователя, по расписанию или по событию (подключение к Wi-Fi, тихий период).
Важнейшее свойство устойчивой синхронизации — идемпотентность операций. Клиент должен иметь возможность повторно отправить одно и то же действие (например, «сохранить заказ №123»), не опасаясь дублирования на сервере. Это достигается через уникальные идентификаторы операций, версионирование сущностей или применение операций в виде diff-патчей.
Автономное приложение обязано вести учёт состояния синхронизации для каждой сущности: «сохранено локально», «ожидает отправки», «отправлено, ожидает подтверждения», «синхронизировано», «обнаружен конфликт». Такая метаинформация хранится вместе с данными и управляет поведением интерфейса (например, серый значок галочки превращается в зелёный после подтверждения сервером).
Service Worker и фоновые API
Service Worker
Service Worker — промежуточный прокси-обработчик, встроенный в стек сетевых вызовов браузера. После регистрации и активации он перехватывает все исходящие fetch-запросы от страницы, а также получает события от системных API: push, notificationclick, sync, backgroundfetchsuccess и др.
Важнейшие особенности Service Worker’а:
- Работает в отдельном глобальном контексте, изолированном от
window. Нет доступа к DOM,localStorage, синхронным XHR. - Запускается по событию, а не постоянно. После обработки события он «усыпляется», что экономит ресурсы.
- Может быть активен, даже когда ни одна вкладка приложения не открыта — если есть ожидающие события (например, push-уведомление или отложенная синхронизация).
- Имеет доступ к IndexedDB, Cache API, Notifications API, Push API, Background Sync API — то есть ко всем инструментам, необходимым для автономной работы.
Service Worker не делает приложение автономным сам по себе, он предоставляет платформу, на которой строятся решения: кэширование ресурсов, перехват запросов к API, постановка задач в очередь синхронизации, реакция на сетевые изменения. Реализация логики автономности — задача разработчика приложения.
Service Worker не является «демоном», постоянно висящим в памяти. Его поведение строго регламентировано событийной моделью и политиками энергосбережения. Понимание жизненного цикла критично для корректной реализации фоновых задач.
Регистрация происходит с помощью navigator.serviceWorker.register(). Браузер скачивает скрипт, проверяет его синтаксис и запускает в отдельном потоке. На этом этапе ещё нет перехвата сетевых запросов — Service Worker находится в состоянии installing.
Установка (install) — фаза, в которой приложение может подготовить начальное состояние: создать кэш, инициализировать базу данных, загрузить статические ресурсы. Обработчик события install должен вызвать event.waitUntil(), передав промис, завершение которого сигнализирует об успешной установке. Пока этот промис не разрешён, Service Worker остаётся в состоянии installing и не активируется. Это позволяет гарантировать, что все необходимые ресурсы доступны до начала перехвата запросов.
Активация (activate) происходит после завершения установки и после того, как все вкладки, использующие предыдущую версию Service Worker’а, будут закрыты (если не вызван skipWaiting()). На этапе активации обычно производится миграция данных — очистка устаревших кэшей, обновление схем IndexedDB. После активации Service Worker получает статус active и начинает перехватывать сетевые события.
Выполнение по событию — основной режим работы. Service Worker «просыпается» только при наступлении события:
fetch— исходящий сетевой запрос от страницы;push— получение push-сообщения от сервера;notificationclick— клик по уведомлению;sync— событие фоновой синхронизации;backgroundfetchsuccess/backgroundfetchfail— завершение фоновой загрузки.
После завершения обработчика события Service Worker, если у него нет ожидающих асинхронных задач (например, незавершённых промисов, зарегистрированных через event.waitUntil()), немедленно терминируется. Это гарантирует, что произвольный код не будет работать в фоне бесконтрольно.
Изоляция Service Worker’а выражается не только в отсутствии доступа к DOM. Он работает в своём собственном глобальном контексте (self), имеет отдельный event loop, не разделяет память с вкладками, и не может напрямую взаимодействовать с ними. Обмен данными возможен только через:
postMessage()— явная передача сообщений между страницей и Service Worker’ом;- косвенное взаимодействие через общие хранилища (IndexedDB, Cache API);
- инициирование событий на странице (например, отправка уведомления, на которое пользователь может кликнуть).
Эта изоляция — не ограничение, а мера безопасности. Она предотвращает утечку сессий, кражу данных через вредоносные скрипты и несанкционированное потребление ресурсов.
Фоновая синхронизация (Background Sync)
Background Sync API решает задачу: «Как гарантировать, что действие пользователя будет выполнено, даже если он закрыл приложение до завершения отправки?»
Без этого API классическая реализация выглядит так: пользователь нажимает «Отправить», приложение делает fetch(), и если соединение нестабильно — запрос падает с ошибкой, интерфейс показывает «Не удалось отправить», и пользователь вынужден повторять действие. Это нарушает принцип «предварительного подтверждения» (optimistic UI), снижает доверие к приложению и ухудшает UX.
Background Sync позволяет сделать шаг вперёд: после нажатия «Отправить» приложение немедленно обновляет интерфейс (например, добавляет сообщение в чат с серой галочкой) и регистрирует задачу синхронизации. Даже если вкладка будет закрыта, браузер сохранит эту задачу и выполнит её в фоне, как только появится интернет.
Техническая последовательность:
- Приложение регистрирует sync-метку через
navigator.serviceWorker.ready.then(reg => reg.sync.register('send-message')).
Метка ('send-message') — это идентификатор типа задачи, который будет передан Service Worker’у в событииsync. - Service Worker обрабатывает событие
syncс соответствующей меткой. Внутри обработчика он извлекает данные из локального хранилища (например, из очереди сообщений в IndexedDB), отправляет их на сервер, и по результату обновляет статус в базе («отправлено»). - Если отправка завершается неудачей (например, сервер недоступен), Service Worker не подтверждает обработку события — и браузер автоматически повторит попытку позже, с экспоненциальной задержкой.
Важно: Background Sync — это не push, а pull со стороны клиента. Сервер не инициирует синхронизацию; она запускается клиентом по событию (регистрация метки) и выполняется браузером по своему усмотрению, с учётом состояния сети, заряда батареи и политики энергосбережения.
Требования и ограничения
- Доступен только в безопасном контексте (
httpsилиlocalhost). - Требует наличия активного Service Worker’а.
- Не гарантирует момент выполнения — браузер может отложить синхронизацию на часы или даже дни, если устройство в режиме энергосбережения.
- На мобильных устройствах (особенно iOS) реализация может быть ограничена: Safari поддерживает Background Sync только начиная с версии 16.4, и даже там поведение менее предсказуемо, чем в Chromium.
- Каждая метка синхронизации уникальна в рамках одного Service Worker’а. Повторная регистрация той же метки не создаёт новую задачу, а обновляет существующую (если она ещё не выполнена).
Практическое использование
Рекомендуется использовать Background Sync не для каждой операции, а для группировки логически связанных действий. Например, вместо регистрации отдельной синхронизации на каждое сообщение в чате — накапливать сообщения в локальной очереди и запускать одну синхронизацию при первом добавлении или при выходе из приложения. Это снижает нагрузку на сеть и упрощает обработку ошибок.
Фоновое извлечение (Background Fetch)
Background Fetch API расширяет идею фоновой синхронизации, но в обратном направлении: не отправка данных на сервер, а массовая загрузка данных с сервера, даже когда приложение неактивно.
Он решает задачи:
- Предзагрузка контента для офлайн-доступа (выпуски подкастов, эпизоды сериала, обновления справочников).
- Загрузка больших файлов без риска прерывания при сворачивании приложения.
- Обновление кэша в фоне без блокировки UI.
Принцип работы:
- Приложение инициирует фоновую загрузку через
reg.backgroundFetch.fetch('podcast-ep123', [request1, request2], options).
Можно передать массив запросов (например, аудиофайл + JSON-описание), а также параметры: отображать ли прогресс в уведомлении, можно ли использовать мобильный интернет. - Браузер берёт управление на себя: он показывает системное уведомление с прогресс-баром, управляет повторными попытками при обрыве, учитывает политики трафика (например, не загружать по мобильной сети без разрешения).
- По завершении (успешном или нет) браузер запускает Service Worker и передаёт ему событие
backgroundfetchsuccessилиbackgroundfetchfail. - В обработчике Service Worker получает доступ к загруженным данным через
event.fetches, проверяет их целостность, сохраняет в IndexedDB или Cache Storage, и может отправить пользователю итоговое уведомление («Эпизод готов к прослушиванию»).
Ключевое преимущество перед ручной реализацией через fetch + Service Worker — делегирование управления жизненным циклом загрузки браузеру. Разработчику не нужно:
- вручную обрабатывать обрывы соединения,
- управлять повторными запросами,
- синхронизировать прогресс между вкладками,
- беспокоиться о том, убит ли процесс при сворачивании.
Background Fetch — это «загрузка как сервис»: надёжная, наблюдаемая, энергоэффективная.
Ограничения
- Поддерживается только в Chromium-браузерах (Chrome, Edge, Opera). Firefox и Safari — не реализованы.
- Требует явного разрешения на показ уведомлений (поскольку прогресс отображается в них).
- Объём загружаемых данных может быть ограничен браузером (обычно — до нескольких гигабайт, но конкретные лимиты зависят от устройства и ОС).
- Нельзя получить прямой доступ к данным до обработки события в Service Worker’е — они хранятся во временной зоне, защищённой от прямого чтения страницей.
Безопасность и согласие пользователя
Фоновая работа расширяет возможности веб-платформы, но одновременно усиливает риски: несанкционированное потребление трафика, энергии, слежка за поведением пользователя. Поэтому все ключевые API работают только при соблюдении строгих условий:
- Безопасный контекст (
HTTPS). Исключение —localhostдля разработки. - Явное согласие на уведомления. Push-уведомления и прогресс фоновой загрузки требуют вызова
Notification.requestPermission(). Без разрешения — никаких фоновых событий, связанных с уведомлениями. - Пользовательский жест. Регистрация Background Sync или Background Fetch должна происходить в контексте действия пользователя (клик, нажатие Enter). Автоматическая регистрация при загрузке страницы запрещена.
- Прозрачность. Браузеры обязаны предоставлять пользователю контроль: в настройках можно отключить фоновую синхронизацию, push-уведомления, фоновую загрузку для конкретного сайта.
Эти ограничения не являются «недостатками», а частью модели доверия веб-платформы. Они позволяют разработчикам строить мощные приложения, не жертвуя конфиденциальностью и контролем со стороны пользователя.
Стратегии управления данными и пользовательский опыт в автономных приложениях
Возвратный кэш (Stale-While-Revalidate)
Stale-While-Revalidate (SWR) — не только HTTP-директива (Cache-Control: stale-while-revalidate=<delta-seconds>), но и архитектурный паттерн, впервые популяризированный в библиотеке swr и адаптированный для Service Worker’ов.
Суть паттерна:
Показать устаревшие, но уже имеющиеся данные немедленно, параллельно инициировав фоновую проверку их актуальности.
Это радикально отличается от классических подходов:
- Cache-First — возвращает кэш, но не обновляет его до следующего запроса (риск показа сильно устаревших данных).
- Network-First — ждёт ответа от сервера, блокируя интерфейс при сетевых задержках или отсутствии связи.
- Network-Only — игнорирует кэш полностью.
SWR обеспечивает немедленный отклик интерфейса при первом открытии (например, списка новостей), а через доли секунды — автоматическое обновление, если сервер вернул более свежие данные. При этом, если сеть недоступна, пользователь всё равно видит содержимое, а не пустой экран с индикатором загрузки.
В Service Worker’е паттерн реализуется следующим образом:
- При получении
fetch-события проверяется наличие ресурса в кэше. - Если есть — немедленно возвращается ответ из кэша (
event.respondWith(cachedResponse)). - Параллельно, в фоне (без ожидания через
event.waitUntil()), запускается запрос к серверу. - При получении свежего ответа кэш обновляется; если данные изменились — посылается сообщение в открытые вкладки (
clients.claim()+postMessage()), чтобы интерфейс обновился без перезагрузки.
SWR особенно эффективен для:
- часто читаемых, редко изменяемых ресурсов (статические страницы, справочники, профили);
- данных, где допустима кратковременная неактуальность (новостные ленты, рейтинги);
- инициализации UI при первом запуске приложения.
Ограничение: не подходит для операций, требующих строгой согласованности (например, проверка баланса перед оплатой). В таких случаях применяется гибридный подход: SWR для чтения, Network-First или Network-Only для записи.
Классификация стратегий кэширования
Помимо SWR, в автономных приложениях применяются следующие фундаментальные стратегии (в терминах Workbox, но применимые и вручную):
-
Cache-Only — возвращать только из кэша. Используется для критически важных статических ресурсов (HTML-оболочка, иконки, скрипты), гарантируя доступность приложения даже при полном отсутствии сети. Рискован для динамических данных.
-
Network-Only — игнорировать кэш, всегда обращаться к серверу. Применяется для операций, где актуальность обязательна (аутентификация, финансовые операции).
-
Cache-First (с fallback) — сначала кэш, при отсутствии — сеть. Основной паттерн для статики и медленно меняющихся данных. Часто дополняется фоновым обновлением кэша после ответа.
-
Network-First (с fallback) — сначала сеть, при ошибке — кэш. Подходит для динамических данных, где важна свежесть, но допустимо показать устаревшую версию при сбое (например, лента сообщений в чате).
-
Stale-While-Revalidate — как описано выше: отдача кэша + фоновый запрос.
Выбор стратегии зависит от семантики ресурса, а не от его типа. Один и тот же эндпоинт /api/user может обрабатываться по SWR при открытии профиля (пользователь увидит своё имя мгновенно), но по Network-First при попытке смены email (требуется свежее состояние сервера для валидации).
Управление состоянием
Автономность требует смены фокуса: вместо хранения данных — хранение операций.
Классическая онлайн-модель:
UI → отправка запроса → обновление состояния после ответа.
Автономная модель:
UI → запись операции в локальную очередь → оптимистичное обновление UI → фоновая отправка операции → подтверждение/откат по результату.
Каждая операция должна содержать:
- Уникальный идентификатор (UUID), чтобы избежать дублирования при повторных отправках;
- Ссылку на сущность (например,
postId: 'abc-123'); - Тип действия (
create,update,delete,patch); - Полезную нагрузку (новые значения полей, delta-изменения);
- Метаданные: временная метка, версия сущности, идентификатор сессии.
Пример структуры операции:
{
"id": "op-9f3a2b1c",
"entity": "comment",
"entityId": "cmt-550e8400",
"action": "create",
"payload": {
"text": "Отличная статья!",
"authorId": "usr-abc",
"postId": "post-xyz"
},
"meta": {
"timestamp": 1732194800000,
"version": 1,
"sessionId": "sess-7d8f"
}
}
Очередь операций хранится в IndexedDB (обычно в отдельной таблице pendingOperations). Service Worker при событии sync извлекает операции, отправляет их на сервер в пакетах (для снижения количества запросов), и по получении подтверждения удаляет их из очереди. При ошибке — сохраняет с новой меткой повтора или помечает как конфликтную.
Такой подход обеспечивает:
- Устойчивость к перезапуску — операции не теряются при закрытии вкладки;
- Идемпотентность — сервер может обрабатывать одну операцию многократно без последствий;
- Трассируемость — лог операций позволяет диагностировать сбои синхронизации.
Разрешение конфликтов
Конфликт возникает, когда локальное и удалённое состояния одной сущности расходятся в результате независимых изменений. Пример: пользователь редактирует пост в офлайне, а в это время модератор удаляет его на сервере.
Автономное приложение обязано иметь стратегию разрешения конфликтов. Она может быть:
- Автоматической (по правилам);
- Полуавтоматической (с участием пользователя);
- Ручной (только через интерфейс разрешения).
Автоматические стратегии
- Победа сервера — локальные изменения отменяются. Подходит для систем, где сервер — единственный источник истины (например, банковские операции).
- Победа клиента — серверные изменения перезаписываются. Опасно, но допустимо для персональных черновиков.
- Слияние по полям — для каждого поля выбирается значение по правилу: «самое новое», «не null», «по приоритету источника». Требует метаданных изменения (кто, когда, какое поле).
- Векторные часы / CRDT — распределённые структуры данных, гарантирующие конвергенцию без центрального координатора. Применяются в продвинутых системах (например, совместные редакторы), но сложны в реализации.
UX при конфликте
Интерфейс не должен «молчать». При обнаружении конфликта:
- Пользователь получает уведомление: «Обнаружено расхождение. Ваша версия и серверная версия отличаются»;
- Предлагается выбор: «Оставить мою», «Принять серверную», «Объединить вручную»;
- В случае ручного объединения — показываются обе версии (например, в split-view), и пользователь выбирает фрагменты.
Важно: конфликт — не ошибка, а нормальная ситуация в распределённых системах. Хороший UX превращает её в контролируемый процесс, а не в катастрофу.
UX-паттерны автономной работы
Техническая реализация бессмысленна без продуманного взаимодействия с пользователем. Ключевые принципы:
-
Прозрачность состояния сети
Не скрывать факт офлайна. Индикатор (например, иконка с перечёркнутым облаком) должен быть видим, но не агрессивен. Лучше — интегрировать в контекст: «Сохранено локально. Отправится при подключении» рядом с сообщением. -
Оптимистичные обновления
Реакция на действия должна быть мгновенной: сообщение появляется в чате, задача — в списке, черновик — в редакторе. Отложенный характер синхронизации не должен ощущаться как задержка. -
Прогресс, а не спиннеры
При фоновой синхронизации или загрузке — показывать прогресс: «3 из 5 сообщений отправлено», «Загружено 45 %». Это снижает тревожность. -
Управляемость
Пользователь должен иметь возможность:- вручную запустить синхронизацию;
- отменить ожидающую операцию;
- очистить локальные черновики;
- переключить режим кэширования (например, «только по Wi-Fi»).
-
Обратная связь по результату
После завершения фоновой задачи — уведомление не «Синхронизация выполнена», а «Сообщения доставлены», «Документ сохранён на сервере». Конкретика важна. -
Гарантии, а не обещания
Избегать формулировок вроде «Данные будут отправлены позже». Лучше: «Данные сохранены локально и отправятся автоматически при восстановлении связи». Акцент на факте (сохранено), а не на будущем действии.
Тестирование автономности
Проверка работы без интернета не сводится к отключению Wi-Fi в DevTools. Необходимо моделировать:
- Частичную доступность — DNS работает, TCP — нет; HTTP 200 приходит, но тело обрезано; сервер возвращает 5xx.
- Переходы между режимами — онлайн → офлайн во время отправки; офлайн → онлайн при открытой вкладке.
- Закрытие приложения в момент синхронизации — корректно ли восстанавливается очередь?
- Долгосрочное хранение — данные, созданные месяц назад, корректно синхронизируются сегодня?
- Конфликты версий — имитация одновременного редактирования с другого устройства.
Инструменты:
- DevTools → Application → Service Workers (вручную остановить/активировать SW);
- Network Throttling («Offline», «Slow 3G»);
- Плагины для тестирования IndexedDB (например,
idb-keyvalс логированием); - E2E-тесты с принудительным отключением сети между шагами (Cypress, Playwright).